S3に置いたPDFをテキストに変換するLambda関数+LambdaレイヤーをCFnで構築する
はじめに
故あって S3 バケットに PDF を置いたらその内容のテキストを処理して結果をまた S3 に置く という機能を実装する機会がありました。勉強もかねて調べながらやっていたところで、ある程度かたちになってきたので、コアとなるところをブログにしておきたいと思います。
Goal
下記のような状態をゴールとします。
- 特定の S3 バケットに PDF を置いたら、テキストデータだけ抽出して別の S3 バケットに保存される環境を作る
- PDF -> テキストの変換は AWS Lambda を使う(言語は Python 3.6 )
- AWS Lambda Layer を使う
- マネジメントコンソールのインラインエディタでコードを確認したいから
- CloudFormation (CFn) を使う
- Python の native module は Docker を使ってなんとかする
- 手元の環境に左右されないようにするため
- Serverless Framework は 使わない
正直 Serverless Framework を使った方がいろいろと便利だし使わない理由もないと思うんですが、個人的な勉強も兼ねていたので抽象度の低いところから始めました。そのほうが個々の動作を把握しやすいという老害的な理由です。
謝辞
下記を実装するに当たって、多方面のサイトを参考にさせてもらいました。この場を借りて感謝致します。
- 【Python】pdfから文字を抽出。pdfminer.sixの使い方
- PDF から テキストデータを抽出するコードはほぼそのまま利用させて頂きました
- S3にcreateObjectをトリガーにLambdaを起動するCloudformationテンプレート | DevelopersIO
- CloudFormation テンプレートは一部そのまま使わせて頂きました
- An easier way to build AWS Lambda deployment packages — with Docker instead of EC2
- Python の native module を ZIP する部分で参考にさせてもらいました
特にこちらの記事は、Python でなく Node.js を使う場合には是非ご参照下さい。
また、もちろん AWS 公式ドキュメントもあちこち参考にしています。コード内の細かいところはこちらも参照してください。
- AWS::Lambda::Function - AWS CloudFormation
- AWS::Lambda::LayerVersion - AWS CloudFormation
- AWS Lambda Resource Types Reference - AWS CloudFormation
- AWS Lambda レイヤー - AWS Lambda
- デプロイパッケージの作成 - AWS Lambda
- Python の AWS Lambda デプロイパッケージ - AWS Lambda
- サンプル Amazon Simple Storage Service 関数コード - AWS Lambda
- AWS Lambda Blueprint(設計図)
s3-get-object-python3
動作
簡単に図解しました。
構築時(薄黄)
- 作業者の手元で ZIPファイル(Lambda 関数、Lambda レイヤー)を作成
- Lambda レイヤー用の ZIP ファイルには Python の native module が含まれるため、Docker を使用して作成する
- Docker イメージは Amazon Linux (AL1) を使用(AWS Lambda 環境準拠)
- amazonlinux - Docker Hub
- 準備用の S3 バケットに ZIP ファイルを置く
- 準備用の S3 バケットは別途用意してください
- CFn がそれらを読み込みつつ必要な環境を構築する
- Lambda 関数, Lambda Layer, IAMロール/ポリシー
- S3 バケット x2
動作時(薄赤 -> 薄青、薄緑)
- トリガー用の S3 バケットに PDF ファイルを置く(薄赤)
- それを検知して Lambda が動作し、良い具合に処理してテキストデータを生成
- 必要な Python モジュールは全て Layer にまとめる
- 動作時のログは CloudWatch Logs へ(薄緑)
- できあがったテキストデータを出力用の S3 バケットに置かれる(薄青)
CFn テンプレートや Lambda コードの類いは後ろにまとめて掲載してます。上述した参考リンク先と合わせてご確認ください。
構築方法
注釈を交えながら解説します。
下記手順を実行すると AWS に対する課金が発生します、ご注意下さい!
0. 前提
以下、下記の環境が整っている前提でご説明します。
- AWS CLI が使える
- Docker が使える
下記説明で使用するファイル・コードは記事末尾にまとめて掲載していますので、もし試される婆は、そちらをお手元に用意しつつご覧ください。
1. ZIP ファイルの作成
Docker コンテナ内でシェルスクリプト( create_lambda_zip.sh
)を動作させます。Docker が動作する環境1で実行してください。
これにより、必要な Python パッケージを python/
以下にまとめて layer.zip
を作成し、同時に Lambda 関数用の Python スクリプト( lambda_function.py
)を圧縮して lambda.zip
とします。
手元の環境( MacBook Pro 2017 )だと実行には 1分半 〜 2分程度かかりましたが、このあたりは回線の太さや、パッケージリポジトリの負荷・引きにも依ると思われます。
docker run -t --rm -v `pwd`:/home amazonlinux:1 \ /bin/bash -ue /home/create_lambda_zip.sh
ここで多くの方が 「Dockerfile 書けよ」 って突っ込むと思うのですが、なんとなく「コンテナ使い捨てとはこういうことだ!」というイメージがあったのでこうしました。時間がもったいなくない人だけマネしてください。
なお create_lambda_zip.sh
は必要最低限の機能に限定してありますが、実際に使うときには、標準出力を捨てたり逆に環境変数や経過を示すメッセージを出力させたりと、何かと工夫した方がいいと思っています。
2. ZIP ファイルと CFn テンプレートのアップロード
ZIP ファイルができたらそれを S3 にアップロードします。CloudFormation から参照できればいいので、保存する S3 バケットはパブリックである必要はありません。
aws s3 cp lambda.zip s3://<準備用S3バケット名>/ aws s3 cp layer.zip s3://<準備用S3バケット名>/
ちなみにですが、準備用 S3 バケットがまだなければさくっと作ってしまうのも手です。
aws s3 md s3://<準備用S3バケット名>
3. スタック作成(AWS CLI)
それでは CFn のスタックを作成します。問題なければこちらも数分で各種リソースが作成されるでしょう。
STACK_NAME=s3-pdf2txt-poc BUCKET_NAME=<準備用S3バケット名> HASH=<何か適当な文字列> aws cloudformation create-stack \ --stack-name $STACK_NAME \ --parameters \ ParameterKey=S3BucketBuild,ParameterValue=$BUCKET_NAME \ ParameterKey=S3BucketTrigger,ParameterValue=s3-pdf2txt-poc-trigger-$HASH \ ParameterKey=S3BucketOutput,ParameterValue=s3-pdf2txt-poc-output-$HASH \ --template-body file://s3-pdf2txt-poc_cfn.yaml \ --capabilities CAPABILITY_NAMED_IAM
8行目で、CFn テンプレート内のパラメータ S3BucketBuild
を置き換えてます。上で ZIP ファイルをおいた S3 バケット名をここで指定して下さい。
また、作成する S3 バケットは全世界でユニークな名前である必要があるので、ここではぶつからないように適当な文字列(例えば AWS アカウント ID とか、ランダムなハッシュ値とか)をくっつけます。 3 行目の変数にいれておいてください。
S3 バケット名の命名規則についてお詳しくない方は、この機会に下記ドキュメントにも目を通しておきましょう:
もちろんマネジメントコンソールから「スタックの作成」をしても良いのですが、その場合は上記パラメータの修正を Web UI 上で行って下さい。
数分待って、ステータスが CREATE_COMPLETE になれば完了です。
STACK_NAME=s3-pdf2txt-poc aws cloudformation describe-stacks \ --stack-name $STACK_NAME \ --query 'Stacks[].StackStatus'
[ "CREATE_COMPLETE" ]
使い方
現状、CFn によって S3 バケットが 2つ作成された状態と思います。
STACK_NAME=s3-pdf2txt-poc aws s3 ls | grep $STACK_NAME
2019-02-22 14:08:21 s3-pdf2txt-poc-output-XXXXXXXX 2019-02-22 14:09:15 s3-pdf2txt-poc-trigger-XXXXXXXX
このうちトリガー側( s3-pdf2txt-poc-trigger-XXXXXXXX
)に何か PDF ファイル(拡張子 .pdf
)を置いてみて下さい(あまり大きくなく、かつ文字の多いものがお勧めです)。
aws s3 cp sample_pdf_file.pdf s3://s3-pdf2txt-poc-trigger-XXXXXXXX/
置いた PDF ファイル名 + .txt
(上の例なら sample_pdf_file.pdf.txt
)が出力用 S3 バケットに出来ていたら完成です!
aws s3 ls s3://s3-pdf2txt-poc-output-XXXXXXXX/
ダウンロードして適当なテキストエディタで開いてみて下さい。正直 PDF には様々な仕様や方言があって、今回使用した pdfminer.six モジュールで対応しきれないものもあるかもしれません。その場合は各自頑張ってコード書いてみてください!
なお置いた PDF の大きさにもよりますが、変換には時間がかかる場合があります。その場合は CloudWatch Logs をみてみて、変換中なのか、それとも何かしらの理由で異常終了しているのか、確認してみて下さい。
変換にかかる時間について
例えばですが、AWS の ホワイトペーパー にある下記 PDF (15ページ)を変換したところ 30 秒程度かかりました。
- AWS によるサーバーレス多層アーキテクチャ: Amazon API Gateway と AWS Lambda の活用
- 750,186 byte
- AWS-Serverless-Multi-Tier-Architectures_JA.pdf
CloudWatch Logs は下記の通り:
REPORT ... Duration: 29806.45 ms Billed Duration: 29900 ms Memory Size: 128 MB Max Memory Used: 90 MB
一方で、下記の PDF は 2分弱かかりました。
- AWS におけるマイクロサービス
- 4,340,583 byte
- MicroservicesOnAWS-V2_NT0829_SMO_MJ_EditSM_ProofSM_ProofNT.pdf
REPORT ... Duration: 116729.61 ms Billed Duration: 116800 ms Memory Size: 128 MB Max Memory Used: 100 MB
現状は Lambda の実行時間を 5 分( 300 秒)に制限していますので、大きな PDF だと変換しきれないこともあるかもしれません。その場合は CFn テンプレートの 98行目 Timeout
を伸ばしてみて下さい。2
後片付け
CFn で作ってますので、作られたスタックを削除すればひととおりきれいになります。
ただし、作成した S3 バケットにデータ(オブジェクト)が残っているとそこで削除失敗してしまうので、バケットの中を(あるいはバケットごと)削除した後でスタック削除してください。
まとめ
Serverless Framework を使わずに、S3 トリガーの Lambda 環境を作ってみました。動作を知るには抽象度の低いところで手を動かしてみるのが最高ですね!
何かの参考になれば幸いです。
コード
今回使用したコード類を公開します。なるべく動作上必要最低限になるように書いてあるので、エラー処理や例外処理がまったく考えられていません。ご了承下さい。
. ├── lambda_function.py ├── requirements.txt ├── create_lambda_zip.sh └── s3-pdf2txt-poc_cfn.yaml
lambda_function.py
- Lambda 関数のコードそのもの
requirements.txt
- Lambda 関数で使用する Python module のリスト。下のシェルスクリプトが参照する
create_lambda_zip.sh
- Lambda 関数ならびに Lambda Layer 構築で使用する ZIP ファイルを作成するスクリプト。Docker 内で実行される
s3-pdf2txt-poc_cfn.yaml
- CFn テンプレート。S3 バケット x2 と Lambda/Lambda Layer +それに付随する諸々を作成する
lambda_function.py
#!/usr/bin/env python from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter from pdfminer.converter import TextConverter from pdfminer.layout import LAParams from pdfminer.pdfpage import PDFPage from io import StringIO, BytesIO import boto3 import json import urllib from os import environ s3 = boto3.resource('s3') OUTPUT_BUCKET = environ['OUTPUT_BUCKET'] def pdfload(pdf_obj): rsrcmgr = PDFResourceManager() codec = "utf-8" text = "" # PDFから文字を抽出し連結する with StringIO() as output: device = TextConverter( rsrcmgr, output, codec=codec, laparams=LAParams()) # PDFデータをfile object的に扱う with BytesIO(pdf_obj.read()) as input: interpreter = PDFPageInterpreter(rsrcmgr, device) for page in PDFPage.get_pages(input): interpreter.process_page(page) text += output.getvalue() device.close() return (text) # Main def lambda_handler(event, context): # トリガーになったS3オブジェクトの情報を取得 s3_bucket_name = event['Records'][0]['s3']['bucket']['name'] s3_object_name = urllib.parse.unquote_plus( event['Records'][0]['s3']['object']['key'], encoding='utf-8' ) # 出力するCSV名を組み立てる output_csv_filename = f'{s3_object_name}.txt' # PDFデータを読み込み pdf_obj = s3.Object(s3_bucket_name, s3_object_name).get()['Body'] output_text = pdfload(pdf_obj) # TXT出力 s3.Object(OUTPUT_BUCKET, output_csv_filename).put( Body = output_text ) # 戻り return ({"output": f'{output_csv_filename}'})
requirements.txt
pdfminer.six chardet
create_lambda_zip.sh
#!/bin/bash -ue mkdir /home/python cd $_ # パッケージインストール yum -y install git python36 python36-pip zip python3 -m pip install pip --upgrade # Pythonモジュールのダウンロード python3 -m pip install -r /home/requirements.txt -t . rm -rf bin *.dist-info *.egg-info # パーミッション find . -type f -exec chmod 644 {} \; find . -type d -exec chmod 755 {} \; # Zip作成 cd .. zip -r9 - python > layer.zip zip - lambda_function.py > lambda.zip # 後片付け rm -rf /home/python
s3-pdf2txt-poc_cfn.yaml
--- AWSTemplateFormatVersion: "2010-09-09" Description: "s3-pdf2txt-poc" Parameters: LambdaFunctionName: Type: String Default: s3-pdf2txt-poc-lambda LambdaLayerName: Type: String Default: s3-pdf2txt-poc-lambda-layer LambdaRoleName: Type: String Default: s3-pdf2txt-poc-role LambdaPolicyName: Type: String Default: s3-pdf2txt-poc-policy S3BucketBuild: Type: String Default: S3ObjectZipLambda: Type: String Default: lambda.zip S3ObjectZipLayer: Type: String Default: layer.zip S3BucketTrigger: Type: String Default: s3-pdf2txt-poc-trigger-XXXXXXXX S3BucketOutput: Type: String Default: s3-pdf2txt-poc-output-XXXXXXXX Resources: ConvLambdaRole: Type: "AWS::IAM::Role" Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - "sts:AssumeRole" ManagedPolicyArns: - "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess" RoleName: !Ref "LambdaRoleName" ConvLambdaRoleInlinePolicy: Type: "AWS::IAM::Policy" Properties: PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: "s3:Get*" Resource: - !Join - "" - - "arn:aws:s3:::" - !Ref "S3BucketTrigger" - "/*" - Effect: "Allow" Action: "s3:PutObject" Resource: !Join - "" - - "arn:aws:s3:::" - !Ref "S3BucketOutput" - "/*" PolicyName: !Ref "LambdaPolicyName" Roles: - Ref: "ConvLambdaRole" ConvLambdaLayer: Type: "AWS::Lambda::LayerVersion" Properties: CompatibleRuntimes: - python3.6 Content: S3Bucket: !Ref "S3BucketBuild" S3Key: !Ref "S3ObjectZipLayer" LayerName: !Ref "LambdaLayerName" ConvLambda: Type: "AWS::Lambda::Function" Properties: Code: S3Bucket: !Ref "S3BucketBuild" S3Key: !Ref "S3ObjectZipLambda" FunctionName: !Ref "LambdaFunctionName" Environment: Variables: OUTPUT_BUCKET: !Ref "S3BucketOutput" Handler: "lambda_function.lambda_handler" Layers: - !Ref ConvLambdaLayer MemorySize: 128 Role: !GetAtt "ConvLambdaRole.Arn" Runtime: "python3.6" Timeout: 300 ConvLambdaPermission: Type: "AWS::Lambda::Permission" Properties: Action: "lambda:InvokeFunction" FunctionName: !GetAtt - ConvLambda - Arn Principal: "s3.amazonaws.com" SourceArn: !Join - "" - - "arn:aws:s3:::" - !Ref "S3BucketTrigger" SrcS3Bucket: Type: "AWS::S3::Bucket" DependsOn: "ConvLambdaPermission" Properties: BucketName: !Ref 'S3BucketTrigger' NotificationConfiguration: LambdaConfigurations: - Event: "s3:ObjectCreated:*" Filter: S3Key: Rules: - Name: suffix Value: pdf Function: !GetAtt - ConvLambda - Arn DstS3Bucket: Type: "AWS::S3::Bucket" Properties: BucketName: !Ref 'S3BucketOutput'